# Roles and custom claims

> Supacharger separates three concepts:

# Roles and custom claims

Supacharger separates three concepts:

- `role` is Supabase’s database role, normally `authenticated`; do not replace it with an application role.
- `user_role` is the shared, global application role emitted by the custom access-token hook.
- resource roles, such as an organisation owner or member, remain authoritative in membership tables and RLS.

## Enable the canonical hook

The committed configuration is:

```toml
[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/app/custom_access_token_hook"
```

The migration grants only `supabase_auth_admin` permission to read `app.user_roles` and execute the hook. `anon`, `authenticated`, and `public` cannot read the role source or call the hook.

For a hosted project, deploy the migration first, then enable the hook separately in that project's Supabase dashboard:

1. Sign in as an organisation or project **Owner** or **Administrator**. A Developer or Read-Only account cannot update Auth configuration; the dashboard or Management API may return `403`.
2. Open the intended hosted project and go to **Authentication → Hooks**.
3. Find **Custom Access Token**, choose **Postgres Function** (also labelled **SQL** in some dashboard versions), and select `app.custom_access_token_hook`.
4. Enable and save the hook. Do not select a similarly named function in `public` and do not create another function when the canonical migration is already deployed.
5. Sign out of the application completely and sign in again, or explicitly refresh the session, so Supabase issues a new access token.
6. Call `supabase.auth.getClaims()` and confirm that `claims.user_role` is present. An existing token can remain stale even after the hook is enabled.

A local `config.toml` controls the local stack; it does not update the hosted dashboard selection automatically. If the migration ledger is current but a fresh hosted token still lacks `user_role`, re-check the selected project, hook type, schema, function, and the permissions of the dashboard account used to save the setting.

## Extend claims without forking Core

Core owns the hook, Supabase-required claims, `user_role`, and the active-organisation claims. Applications add compact product claims through this developer extension function:

```sql
app.custom_access_token_claims_extension(event jsonb, canonical_claims jsonb)
```

The default function returns `{}`. To extend it, create a **new forward migration** and replace only that function body. A developer-owned example is installed at `supabase/templates/custom-access-token-claims-extension.sql`.

```sql
create or replace function app.custom_access_token_claims_extension(
  event jsonb,
  canonical_claims jsonb
)
returns jsonb
language sql
stable
security invoker
set search_path = ''
as $$
  select jsonb_build_object('product_plan', coalesce(plan.lookup_key, 'free'))
  from app.product_user_plans plan
  where plan.user_id = (event ->> 'user_id')::uuid;
$$;
```

Return only an object of additional product-owned claims. The Core hook rejects attempts to replace Supabase claims, the global role, or active-organisation context. This keeps the contract DRY: Core maintains the secure merge and reserved names while the application owns its product query.

The function runs as `supabase_auth_admin` with `security invoker`. Grant that role only the table access and RLS policy the extension genuinely needs. Preserve the narrow execute grant and revoke browser roles. Keep claims small, avoid personal data, never use user-editable `user_metadata` for authorisation, and remember that values remain stale until the token refreshes.

## Assign a global application role

Roles are administrative data, so change them through a reviewed migration or another separately designed trusted boundary. For example:

```sql
update app.user_roles
set role = 'admin'::app.application_role,
    updated_at = timezone('utc', now())
where user_id = '00000000-0000-0000-0000-000000000000';
```

Do not add `admin` to `user_metadata`; signed-in users can edit that metadata. Do not expose a generic browser RPC that lets a caller choose their own role.

The new claim appears only in newly issued access tokens. After a role change, sign in again or refresh the session:

```ts
const { data, error } = await supabase.auth.refreshSession();
if (error) throw error;

const { data: claimData } = await supabase.auth.getClaims();
console.log(claimData?.claims.user_role);
```

## Use the claim in RLS

Claims can provide a fast coarse-grained check:

```sql
create policy admin_can_read_audit_log
on app.audit_log
for select
to authenticated
using ((select auth.jwt() ->> 'user_role') = 'admin');
```

JWT claims are cached until the access token is refreshed. For access that must be revoked immediately, query authoritative database state in RLS or a server-side domain function instead of relying only on the claim.

## Organisation roles

An organisation role is not a single global user role. One person can own one organisation and be a member of another. Core's canonical roles are `owner`, `admin`, and `member`; keep that source of truth in the membership table:

```sql
create type app.organisation_role as enum ('owner', 'admin', 'member');

create table app.organisation_members (
  organisation_id uuid not null references app.organisations(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role app.organisation_role not null default 'member',
  primary key (organisation_id, user_id)
);
```

Authoritative RLS reads the membership row for the resource being accessed:

```sql
create policy organisation_admins_manage_settings
on app.organisation_settings
for all
to authenticated
using (
  exists (
    select 1
    from app.organisation_members member
    where member.organisation_id = organisation_settings.organisation_id
      and member.user_id = (select auth.uid())
      and member.role in ('owner', 'admin')
  )
);
```

The canonical session context stores a selected organisation against the current Auth `session_id`. The hook emits only that compact context:

```json
{
  "active_organisation_id": "3ae1…",
  "active_organisation_handle": "acme",
  "active_organisation_role": "admin",
  "organisation_context_version": 4
}
```

When the user switches organisation, update the server-owned session context, refresh the JWT, and verify the returned claims. Keep the membership lookup in RLS even when the active context claim is used for navigation or an early rejection. Do not put every organisation membership into the JWT: the claim becomes stale and SSR cookies have practical size limits.

Consumer-specific role names must be mapped at a product boundary or migrated to the canonical enum. They must not silently change the reusable Core claim contract.

## TypeScript claim helper

Narrow custom values before using them:

```ts
type ApplicationRole = 'user' | 'admin';

export async function getApplicationRole(supabase: SupabaseClient) {
  const { data, error } = await supabase.auth.getClaims();
  if (error) throw error;

  const role = data?.claims.user_role;
  const normalisedRole: ApplicationRole = role === 'admin' ? 'admin' : 'user';
  return normalisedRole;
}
```

Use this for display or coarse server routing. Keep final data authorisation in RLS and trusted server code.
